Skip to content

[WIP] Feat/cryptpad onlyoffice#3709

Draft
Crash-- wants to merge 3 commits intomasterfrom
feat/cryptpad-onlyoffice
Draft

[WIP] Feat/cryptpad onlyoffice#3709
Crash-- wants to merge 3 commits intomasterfrom
feat/cryptpad-onlyoffice

Conversation

@Crash--
Copy link
Copy Markdown
Contributor

@Crash-- Crash-- commented Feb 20, 2026

No description provided.

Enable opening and editing office documents (docx/xlsx/pptx) without
a remote OnlyOffice Document Server by using CryptPad's client-side
OnlyOffice wrapper and x2t-wasm converter.

- Add CryptPadView component with mock server protocol handling
- Add x2t-wasm converter for Office <-> OnlyOffice internal format
- Add useCryptPadConfig hook for client-side document loading
- Add isCryptPadEnabled() helper and feature flag toggle
- Support debounced auto-save back to cozy-stack
- Split Editor into CryptPad/Server editor variants
In CryptPad mode, saves are done directly by the client via cozy-client,
not by the OnlyOffice server. The realtime listener detects the md5sum
change and incorrectly warns about external modifications.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 20, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/cryptpad-onlyoffice

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@codescene-delta-analysis codescene-delta-analysis bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gates Failed
New code is healthy (2 new files with code health below 10.00)
Enforce critical code health rules (1 file with Bumpy Road Ahead)

Gates Passed
1 Quality Gates Passed

See analysis details in CodeScene

Reason for failure
New code is healthy Violations Code Health Impact
CryptPadView.jsx 4 rules 7.97 Suppress
converter.js 1 rule 9.69 Suppress
Enforce critical code health rules Violations Code Health Impact
CryptPadView.jsx 1 critical rule 7.97 Suppress

Quality Gate Profile: The Bare Minimum
Install CodeScene MCP: safeguard and uplift AI-generated code. Catch issues early with our IDE extension and CLI tool.

Comment on lines +106 to +246
const CryptPadView = ({ apiUrl, docEditorConfig }) => {
const [isError, setIsError] = useState(false)
const docEditorRef = useRef(null)
const saveTimerRef = useRef(null)
const client = useClient()

const { isEditorReady, setIsEditorReady, editorMode, fileId, file } =
useOnlyOfficeContext()

// Use a ref to always have the current editorMode in message handlers
const editorModeRef = useRef(editorMode)
useEffect(() => {
editorModeRef.current = editorMode
}, [editorMode])

/**
* Save the current document back to cozy-stack.
* Gets the .bin content from the editor, converts it back to the
* original format (docx/xlsx/pptx), and uploads it.
*/
const saveDocument = useCallback(async () => {
console.log('[CryptPad] saveDocument called, mode:', editorModeRef.current)
if (editorModeRef.current !== 'edit') return

const editor = getOOEditor()
console.log('[CryptPad] editor instance:', editor ? 'found' : 'NOT FOUND')
if (!editor) {
console.warn('[CryptPad] Cannot save: editor not available')
return
}

try {
// Get the document content from the editor.
// asc_nativeGetFile2 returns a base64-encoded string of the internal
// DOCY/XLSY/PPTY format. We try multiple methods as fallbacks.
let exportData = null
for (const method of ['asc_nativeGetFile2', 'asc_nativeGetFile', 'asc_nativeGetFile3']) {
try {
if (typeof editor[method] !== 'function') continue
const result = editor[method]()
if (result && (result.byteLength || result.length) > 0) {
exportData = result
console.log(`[CryptPad] ${method} returned ${typeof result}, length: ${result.byteLength || result.length}`)
break
}
} catch (e) {
console.warn(`[CryptPad] ${method} failed:`, e.message)
}
}

if (!exportData) {
console.warn('[CryptPad] Cannot save: no export method returned data')
return
}

// The editor returns a base64-encoded string of the internal format
// (starts with "DOCY;", "XLSY;", or "PPTY;"). Decode it to binary.
let rawData
if (typeof exportData === 'string') {
const binaryString = atob(exportData)
rawData = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
rawData[i] = binaryString.charCodeAt(i)
}
console.log('[CryptPad] Decoded base64 →', rawData.byteLength, 'bytes, header:', String.fromCharCode(...rawData.slice(0, 12)))
} else {
// Typed array from iframe — copy cross-frame
const len = exportData.byteLength ?? exportData.length ?? 0
rawData = new Uint8Array(len)
for (let i = 0; i < len; i++) {
rawData[i] = exportData[i]
}
}

const ext = getFileExtension(file.name)

// Convert internal format back to the original Office format
const fileData = await convertFromInternal(rawData, ext)
console.log('[CryptPad] Converted back to', ext, ':', fileData.byteLength, 'bytes')

// Verify fileData is a valid main-frame Uint8Array
console.log('[CryptPad] fileData type:', fileData.constructor.name,
'instanceof Uint8Array:', fileData instanceof Uint8Array,
'byteLength:', fileData.byteLength,
'first 4 bytes:', Array.from(fileData.slice(0, 4)))

const blob = new Blob([fileData], { type: file.mime })
console.log('[CryptPad] Blob created:', blob.size, 'bytes, type:', blob.type)

// Upload back to cozy-stack, overwriting the existing file
const resp = await client
.collection(DOCTYPE_FILES)
.updateFile(blob, {
fileId,
name: file.name,
contentLength: blob.size
})
console.log('[CryptPad] Save response:', JSON.stringify({
id: resp?.data?.id || resp?.data?._id,
size: resp?.data?.size || resp?.data?.attributes?.size,
rev: resp?.data?._rev || resp?.data?.meta?.rev,
name: resp?.data?.name || resp?.data?.attributes?.name
}))
} catch (error) {
console.error('[CryptPad] Failed to save document:', error)
}
}, [client, file, fileId])

/**
* Schedule a debounced save. Called after each `saveChanges` message
* from the editor. Waits 2 seconds of inactivity before saving to
* avoid uploading on every keystroke.
*/
const debouncedSave = useCallback(() => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null
saveDocument()
}, 2000)
}, [saveDocument])

/**
* Initialize the CryptPad-wrapped OnlyOffice editor.
* The wrapper's api.js replaces DocsAPI.DocEditor with its own class
* that supports connectMockServer().
*/
const initEditor = useCallback(() => {
try {
// CryptPad's wrapper expects window.APP to exist — it sets
// window.APP.getImageURL during connectMockServer().
if (!window.APP) {
window.APP = {}
}

// Intercept /downloadas/ HTTP requests that the editor makes
// when trying to save through the (non-existent) Document Server.
patchXHRForDownloadAs()

// Also patch the iframe's XHR once it's created
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: Complex Method
CryptPadView (top-level context) has a cyclomatic complexity of 40, threshold = 10

Suppress

Comment on lines +19 to +46
const getOOEditor = () => {
const iframe = document.getElementsByName(EDITOR_IFRAME_NAME)[0]
if (!iframe || !iframe.contentWindow) return null

const win = iframe.contentWindow

// OnlyOffice exposes different editor objects depending on document type.
// Log available objects for debugging.
const found =
win.editor || win.editorCell || win.editorPresentation || null

if (!found) {
// Look for the editor API on the Asc global (sdkjs exposes it there)
const api =
win.Asc && (win.Asc.editor || win.Asc.spreadsheet_api || null)
if (api) return api

console.warn('[CryptPad] Editor not found on iframe window. Available keys:',
Object.keys(win).filter(k =>
k.toLowerCase().includes('editor') ||
k.toLowerCase().includes('asc') ||
k.toLowerCase().includes('api')
)
)
}

return found
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: Complex Method
getOOEditor has a cyclomatic complexity of 13, threshold = 10

Suppress

try {
if (typeof editor[method] !== 'function') continue
const result = editor[method]()
if (result && (result.byteLength || result.length) > 0) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: Complex Conditional
CryptPadView (top-level context) has 1 complex conditionals with 2 branches, threshold = 2

Suppress

Comment on lines +106 to +246
const CryptPadView = ({ apiUrl, docEditorConfig }) => {
const [isError, setIsError] = useState(false)
const docEditorRef = useRef(null)
const saveTimerRef = useRef(null)
const client = useClient()

const { isEditorReady, setIsEditorReady, editorMode, fileId, file } =
useOnlyOfficeContext()

// Use a ref to always have the current editorMode in message handlers
const editorModeRef = useRef(editorMode)
useEffect(() => {
editorModeRef.current = editorMode
}, [editorMode])

/**
* Save the current document back to cozy-stack.
* Gets the .bin content from the editor, converts it back to the
* original format (docx/xlsx/pptx), and uploads it.
*/
const saveDocument = useCallback(async () => {
console.log('[CryptPad] saveDocument called, mode:', editorModeRef.current)
if (editorModeRef.current !== 'edit') return

const editor = getOOEditor()
console.log('[CryptPad] editor instance:', editor ? 'found' : 'NOT FOUND')
if (!editor) {
console.warn('[CryptPad] Cannot save: editor not available')
return
}

try {
// Get the document content from the editor.
// asc_nativeGetFile2 returns a base64-encoded string of the internal
// DOCY/XLSY/PPTY format. We try multiple methods as fallbacks.
let exportData = null
for (const method of ['asc_nativeGetFile2', 'asc_nativeGetFile', 'asc_nativeGetFile3']) {
try {
if (typeof editor[method] !== 'function') continue
const result = editor[method]()
if (result && (result.byteLength || result.length) > 0) {
exportData = result
console.log(`[CryptPad] ${method} returned ${typeof result}, length: ${result.byteLength || result.length}`)
break
}
} catch (e) {
console.warn(`[CryptPad] ${method} failed:`, e.message)
}
}

if (!exportData) {
console.warn('[CryptPad] Cannot save: no export method returned data')
return
}

// The editor returns a base64-encoded string of the internal format
// (starts with "DOCY;", "XLSY;", or "PPTY;"). Decode it to binary.
let rawData
if (typeof exportData === 'string') {
const binaryString = atob(exportData)
rawData = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
rawData[i] = binaryString.charCodeAt(i)
}
console.log('[CryptPad] Decoded base64 →', rawData.byteLength, 'bytes, header:', String.fromCharCode(...rawData.slice(0, 12)))
} else {
// Typed array from iframe — copy cross-frame
const len = exportData.byteLength ?? exportData.length ?? 0
rawData = new Uint8Array(len)
for (let i = 0; i < len; i++) {
rawData[i] = exportData[i]
}
}

const ext = getFileExtension(file.name)

// Convert internal format back to the original Office format
const fileData = await convertFromInternal(rawData, ext)
console.log('[CryptPad] Converted back to', ext, ':', fileData.byteLength, 'bytes')

// Verify fileData is a valid main-frame Uint8Array
console.log('[CryptPad] fileData type:', fileData.constructor.name,
'instanceof Uint8Array:', fileData instanceof Uint8Array,
'byteLength:', fileData.byteLength,
'first 4 bytes:', Array.from(fileData.slice(0, 4)))

const blob = new Blob([fileData], { type: file.mime })
console.log('[CryptPad] Blob created:', blob.size, 'bytes, type:', blob.type)

// Upload back to cozy-stack, overwriting the existing file
const resp = await client
.collection(DOCTYPE_FILES)
.updateFile(blob, {
fileId,
name: file.name,
contentLength: blob.size
})
console.log('[CryptPad] Save response:', JSON.stringify({
id: resp?.data?.id || resp?.data?._id,
size: resp?.data?.size || resp?.data?.attributes?.size,
rev: resp?.data?._rev || resp?.data?.meta?.rev,
name: resp?.data?.name || resp?.data?.attributes?.name
}))
} catch (error) {
console.error('[CryptPad] Failed to save document:', error)
}
}, [client, file, fileId])

/**
* Schedule a debounced save. Called after each `saveChanges` message
* from the editor. Waits 2 seconds of inactivity before saving to
* avoid uploading on every keystroke.
*/
const debouncedSave = useCallback(() => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null
saveDocument()
}, 2000)
}, [saveDocument])

/**
* Initialize the CryptPad-wrapped OnlyOffice editor.
* The wrapper's api.js replaces DocsAPI.DocEditor with its own class
* that supports connectMockServer().
*/
const initEditor = useCallback(() => {
try {
// CryptPad's wrapper expects window.APP to exist — it sets
// window.APP.getImageURL during connectMockServer().
if (!window.APP) {
window.APP = {}
}

// Intercept /downloadas/ HTTP requests that the editor makes
// when trying to save through the (non-existent) Document Server.
patchXHRForDownloadAs()

// Also patch the iframe's XHR once it's created
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: Bumpy Road Ahead
CryptPadView (top-level context) has 2 blocks with nested conditional logic. Any nesting of 2 or deeper is considered. Threshold is 2 blocks per function

Suppress

@@ -0,0 +1,482 @@
import PropTypes from 'prop-types'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: Overall Code Complexity
This module has a mean cyclomatic complexity of 9.10 across 10 functions. The mean complexity threshold is 4

Suppress

Comment on lines +133 to +172
export async function convert(inputData, inputFormat, outputFormat) {
const { module } = await initX2T()

// Use unique file paths per conversion to avoid race conditions
const conversionId = Math.random().toString(36).substring(2, 10)
const inputExt = DOC_TYPE_EXT[inputFormat] || inputFormat
const outputExt = DOC_TYPE_EXT[outputFormat] || outputFormat
const inputPath = `/working/input-${conversionId}.${inputExt}`
const outputPath = `/working/output-${conversionId}.${outputExt}`
const paramsPath = `/working/params-${conversionId}.xml`

// Write input file to Emscripten virtual FS
module.FS.writeFile(inputPath, inputData)

// Write conversion params
const params = buildParamsXML(inputPath, outputPath, inputFormat, outputFormat)
module.FS.writeFile(paramsPath, params)

// Run conversion
const result = module.ccall('main1', 'number', ['string'], [paramsPath])

if (result !== 0) {
// Clean up
try { module.FS.unlink(inputPath) } catch (e) { /* ignore */ }
try { module.FS.unlink(paramsPath) } catch (e) { /* ignore */ }
throw new Error(`x2t conversion failed with code ${result}`)
}

// Read output — copy to main frame to avoid cross-frame typed array issues
const iframeOutput = module.FS.readFile(outputPath)
const outputData = new Uint8Array(iframeOutput.length)
outputData.set(iframeOutput)

// Clean up virtual FS
try { module.FS.unlink(inputPath) } catch (e) { /* ignore */ }
try { module.FS.unlink(outputPath) } catch (e) { /* ignore */ }
try { module.FS.unlink(paramsPath) } catch (e) { /* ignore */ }

return outputData
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: Complex Method
convert has a cyclomatic complexity of 10, threshold = 9

Suppress

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant